// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License.  You may obtain a copy of the License at
//
//   http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied.  See the License for the
// specific language governing permissions and limitations
// under the License.

#include "kudu/security/test/mini_kdc.h"

#include <csignal>
#include <cstdlib>
#include <map>
#include <memory>
#include <string>
#include <utility>

#include <glog/logging.h>

#include "kudu/gutil/map-util.h"
#include "kudu/gutil/strings/strip.h"
#include "kudu/gutil/strings/substitute.h"
#include "kudu/util/env.h"
#include "kudu/util/monotime.h"
#include "kudu/util/path_util.h"
#include "kudu/util/scoped_cleanup.h"
#include "kudu/util/slice.h"
#include "kudu/util/stopwatch.h"
#include "kudu/util/subprocess.h"
#include "kudu/util/test_util.h"

using std::map;
using std::string;
using std::unique_ptr;
using std::vector;
using strings::Substitute;

namespace kudu {

string MiniKdcOptions::ToString() const {
  return strings::Substitute("{ realm: $0, data_root: $1, port: $2, "
      "ticket_lifetime: $3, renew_lifetime: $4 }",
      realm, data_root, port, ticket_lifetime, renew_lifetime);
}

MiniKdc::MiniKdc()
    : MiniKdc(MiniKdcOptions()) {
}

MiniKdc::MiniKdc(MiniKdcOptions options)
    : options_(std::move(options)) {
  if (options_.realm.empty()) {
    options_.realm = "KRBTEST.COM";
  }
  if (options_.data_root.empty()) {
    // We hardcode "/tmp" here since the original function which initializes a random test
    // directory (GetTestDataDirectory()), depends on gmock.
    options_.data_root = JoinPathSegments("/tmp", "krb5kdc");
  }
  if (options_.ticket_lifetime.empty()) {
    options_.ticket_lifetime = "24h";
  }
  if (options_.renew_lifetime.empty()) {
    options_.renew_lifetime = "7d";
  }
}

MiniKdc::~MiniKdc() {
  if (kdc_process_) {
    WARN_NOT_OK(Stop(), "Unable to stop MiniKdc");
  }
}

map<string, string> MiniKdc::GetEnvVars() const {
  return {
    {"KRB5_CONFIG", JoinPathSegments(options_.data_root, "krb5.conf")},
    {"KRB5_KDC_PROFILE", JoinPathSegments(options_.data_root, "kdc.conf")},
    {"KRB5CCNAME", JoinPathSegments(options_.data_root, "krb5cc")},
    // Enable the workaround for MIT krb5 1.10 bugs from krb5_realm_override.cc.
    {"KUDU_ENABLE_KRB5_REALM_FIX", "yes"}
  };
}

vector<string> MiniKdc::MakeArgv(const vector<string>& in_argv) {
  vector<string> real_argv = { "env" };
  for (const auto& p : GetEnvVars()) {
    real_argv.push_back(Substitute("$0=$1", p.first, p.second));
  }
  for (const string& a : in_argv) {
    real_argv.push_back(a);
  }
  return real_argv;
}

namespace {
// Attempts to find the path to the specified Kerberos binary, storing it in 'path'.
Status GetBinaryPath(const string& binary, string* path) {
  static const vector<string> kCommonLocations = {
    "/usr/local/opt/krb5/sbin", // Homebrew
    "/usr/local/opt/krb5/bin", // Homebrew
    "/opt/homebrew/opt/krb5/sbin", // Homebrew arm
    "/opt/homebrew/opt/krb5/bin", // Homebrew arm
    "/opt/local/sbin", // Macports
    "/opt/local/bin", // Macports
    "/usr/lib/mit/sbin", // SLES
    "/usr/sbin", // Linux
  };
  return FindExecutable(binary, kCommonLocations, path);
}
} // namespace

Status MiniKdc::Start() {
  SCOPED_LOG_SLOW_EXECUTION(WARNING, 100, "starting KDC");
  CHECK(!kdc_process_);
  VLOG(1) << "Starting Kerberos KDC: " << options_.ToString();

  if (!Env::Default()->FileExists(options_.data_root)) {
    VLOG(1) << "Creating KDC database and configuration files";
    RETURN_NOT_OK(Env::Default()->CreateDir(options_.data_root));

    RETURN_NOT_OK(CreateKdcConf());
    RETURN_NOT_OK(CreateKrb5Conf());

    // Create the KDC database using the kdb5_util tool.
    string kdb5_util_bin;
    RETURN_NOT_OK(GetBinaryPath("kdb5_util", &kdb5_util_bin));

    RETURN_NOT_OK(Subprocess::Call(MakeArgv({
        kdb5_util_bin, "create",
        "-s", // Stash the master password.
        "-P", "masterpw", // Set a password.
        "-W", // Use weak entropy (since we don't need real security).
    })));
  }

  // Start the Kerberos KDC.
  string krb5kdc_bin;
  RETURN_NOT_OK(GetBinaryPath("krb5kdc", &krb5kdc_bin));

  kdc_process_.reset(new Subprocess(
      MakeArgv({
      krb5kdc_bin,
      "-n", // Do not daemonize.
  })));

  RETURN_NOT_OK(kdc_process_->Start());

  const bool need_config_update = (options_.port == 0);
  // Wait for KDC to start listening on its ports and commencing operation
  // with a wildcard binding.
  RETURN_NOT_OK(WaitForUdpBind(
      kdc_process_->pid(), &options_.port, {}, MonoDelta::FromSeconds(1)));

  if (need_config_update) {
    // If we asked for an ephemeral port, grab the actual ports and
    // rewrite the configuration so that clients can connect.
    RETURN_NOT_OK(CreateKrb5Conf());
    RETURN_NOT_OK(CreateKdcConf());
  }

  return Status::OK();
}

Status MiniKdc::Stop() {
  if (!kdc_process_) {
    return Status::OK();
  }
  VLOG(1) << "Stopping KDC";
  unique_ptr<Subprocess> proc(kdc_process_.release());
  RETURN_NOT_OK(proc->Kill(SIGKILL));
  RETURN_NOT_OK(proc->Wait());

  return Status::OK();
}

// Creates a kdc.conf file according to the provided options.
Status MiniKdc::CreateKdcConf() const {
  static const string kFileTemplate = R"(
[kdcdefaults]
kdc_ports = $2
kdc_tcp_ports = ""

[realms]
$1 = {
        acl_file = $0/kadm5.acl
        admin_keytab = $0/kadm5.keytab
        database_name = $0/principal
        key_stash_file = $0/.k5.$1
        max_renewable_life = 7d 0h 0m 0s
}
  )";
  string file_contents = strings::Substitute(kFileTemplate, options_.data_root,
                                             options_.realm, options_.port);
  return WriteStringToFile(Env::Default(), file_contents,
                           JoinPathSegments(options_.data_root, "kdc.conf"));
}

// Creates a krb5.conf file according to the provided options.
Status MiniKdc::CreateKrb5Conf() const {
  static const string kFileTemplate = R"(
[logging]
    kdc = FILE:/dev/stderr

[libdefaults]
    default_realm = $1
    dns_lookup_kdc = false
    dns_lookup_realm = false
    forwardable = true
    renew_lifetime = $2
    ticket_lifetime = $3

    # Disable aes256 since Java does not support it without JCE. Java is only
    # one of several minicluster consumers, but disabling aes256 doesn't
    # appreciably hurt Kudu code coverage, so we disable it universally.
    #
    # For more details, see:
    # https://docs.oracle.com/javase/8/docs/technotes/guides/security/jgss/jgss-features.html
    default_tkt_enctypes = aes128-cts des3-cbc-sha1
    default_tgs_enctypes = aes128-cts des3-cbc-sha1
    permitted_enctypes = aes128-cts des3-cbc-sha1

    # In miniclusters, we start daemons on local loopback IPs that
    # have no reverse DNS entries. So, disable reverse DNS.
    rdns = false

    # The server side will start its GSSAPI server using the local FQDN.
    # However, in tests, we connect to it via a non-matching loopback IP.
    # This enables us to connect despite that mismatch.
    ignore_acceptor_hostname = true

[realms]
    $1 = {
        kdc = 127.0.0.1:$0
        # This super-arcane syntax can be found documented in various Hadoop
        # vendors' security guides and very briefly in the MIT krb5 docs.
        # Basically, this one says to map anyone coming in as foo@OTHERREALM.COM
        # and map them to a local user 'other-foo'
        auth_to_local = RULE:[1:other-$$1@$$0](.*@OTHERREALM.COM$$)s/@.*//
    }
  )";
  string file_contents = strings::Substitute(kFileTemplate, options_.port, options_.realm,
                                             options_.renew_lifetime, options_.ticket_lifetime);
  return WriteStringToFile(Env::Default(), file_contents,
                           JoinPathSegments(options_.data_root, "krb5.conf"));
}

Status MiniKdc::CreateUserPrincipal(const string& username) {
  SCOPED_LOG_SLOW_EXECUTION(WARNING, 100, Substitute("creating user principal $0", username));
  string kadmin;
  RETURN_NOT_OK(GetBinaryPath("kadmin.local", &kadmin));
  RETURN_NOT_OK(Subprocess::Call(MakeArgv({
          kadmin, "-q", Substitute("add_principal -pw $0 $0", username)})));
  return Status::OK();
}

Status MiniKdc::CreateServiceKeytab(const string& spn,
                                    string* path) {
  SCOPED_LOG_SLOW_EXECUTION(WARNING, 100, Substitute("creating service keytab for $0", spn));
  string kt_path = spn;
  StripString(&kt_path, "/", '_');
  kt_path = JoinPathSegments(options_.data_root, kt_path) + ".keytab";

  string kadmin;
  RETURN_NOT_OK(GetBinaryPath("kadmin.local", &kadmin));
  RETURN_NOT_OK(Subprocess::Call(MakeArgv({
          kadmin, "-q", Substitute("add_principal -randkey $0", spn)})));
  RETURN_NOT_OK(Subprocess::Call(MakeArgv({
          kadmin, "-q", Substitute("ktadd -k $0 $1", kt_path, spn)})));
  *path = kt_path;
  return Status::OK();
}

Status MiniKdc::RandomizePrincipalKey(const string& spn) {
  SCOPED_LOG_SLOW_EXECUTION(WARNING, 100, Substitute("randomizing key for $0", spn));
  string kadmin;
  RETURN_NOT_OK(GetBinaryPath("kadmin.local", &kadmin));
  RETURN_NOT_OK(Subprocess::Call(MakeArgv({
          kadmin, "-q", Substitute("change_password -randkey $0", spn)})));
  return Status::OK();
}

Status MiniKdc::CreateKeytabForExistingPrincipal(const string& spn) {
  SCOPED_LOG_SLOW_EXECUTION(WARNING, 100, Substitute("creating keytab for $0", spn));
  string kt_path = GetKeytabPathForPrincipal(spn);
  string kadmin;
  RETURN_NOT_OK(GetBinaryPath("kadmin.local", &kadmin));
  RETURN_NOT_OK(Subprocess::Call(MakeArgv({
          kadmin, "-q", Substitute("xst -norandkey -k $0 $1", kt_path, spn)})));
  return Status::OK();
}

string MiniKdc::GetKeytabPathForPrincipal(const string& spn) const {
  string kt_path = spn;
  StripString(&kt_path, "/", '_');
  return JoinPathSegments(options_.data_root, kt_path) + ".keytab";
}

Status MiniKdc::Kinit(const string& username) {
  SCOPED_LOG_SLOW_EXECUTION(WARNING, 100, Substitute("kinit for $0", username));
  string kinit;
  RETURN_NOT_OK(GetBinaryPath("kinit", &kinit));
  unique_ptr<WritableFile> tmp_cc_file;
  string tmp_cc_path;
  string tmp_username = username;
  StripString(&tmp_username, "/", '_');
  const auto tmp_template = Substitute("kinit-temp-$0.XXXXXX", tmp_username);
  WritableFileOptions opts;
  opts.is_sensitive = false;
  RETURN_NOT_OK_PREPEND(Env::Default()->NewTempWritableFile(
      opts,
      JoinPathSegments(options_.data_root, tmp_template),
      &tmp_cc_path, &tmp_cc_file),
      "could not create temporary file");
  auto delete_tmp_cc = MakeScopedCleanup([&]() {
    WARN_NOT_OK(Env::Default()->DeleteFile(tmp_cc_path),
                "could not delete file " + tmp_cc_path);
  });
  RETURN_NOT_OK(Subprocess::Call(MakeArgv({ kinit, "-c", tmp_cc_path, username }), username));
  const auto env_vars_map = GetEnvVars();
  const auto& ccache_path = FindOrDie(env_vars_map, "KRB5CCNAME");
  RETURN_NOT_OK_PREPEND(Env::Default()->RenameFile(tmp_cc_path, ccache_path),
                        "could not move new file into place");
  delete_tmp_cc.cancel();
  return Status::OK();
}

Status MiniKdc::Kdestroy() {
  SCOPED_LOG_SLOW_EXECUTION(WARNING, 100, "kdestroy");
  string kdestroy;
  RETURN_NOT_OK(GetBinaryPath("kdestroy", &kdestroy));
  return Subprocess::Call(MakeArgv({ kdestroy, "-A" }));
}

Status MiniKdc::Klist(string* output) {
  string klist;
  RETURN_NOT_OK(GetBinaryPath("klist", &klist));
  RETURN_NOT_OK(Subprocess::Call(MakeArgv({ klist, "-A" }), "", output));
  return Status::OK();
}

Status MiniKdc::KlistKeytab(const string& keytab_path, string* output) {
  string klist;
  RETURN_NOT_OK(GetBinaryPath("klist", &klist));
  RETURN_NOT_OK(Subprocess::Call(MakeArgv({ klist, "-k", keytab_path }), "", output));
  return Status::OK();
}

Status MiniKdc::SetKrb5Environment() const {
  if (!kdc_process_) {
    return Status::IllegalState("KDC not started");
  }
  for (const auto& p : GetEnvVars()) {
    CHECK_ERR(setenv(p.first.c_str(), p.second.c_str(), 1 /*overwrite*/));
  }

  return Status::OK();
}

} // namespace kudu
